iT邦幫忙

2025 iThome 鐵人賽

DAY 5
0

今天要做什麼?

昨天我們學會了測試結構與組織,但隨著測試越寫越多,你可能遇到一個問題:「為什麼這個測試單獨執行會通過,但和其他測試一起執行時會失敗?」

想像一個場景:你為數學工具庫新增了一個 CalculatorWithHistory 類別,它會記錄計算歷史。第一個測試執行時歷史是空的,測試通過;但第二個測試執行時,歷史裡已經有第一個測試留下的資料,導致測試失敗。這就是「測試污染」問題。

今天我們要學習「測試生命週期」,了解如何在每個測試執行前後進行適當的設置和清理,讓每個測試都能在乾淨、一致的環境中執行。

學習目標

今天結束後,你將學會:

  • 理解測試生命週期的重要性
  • 掌握 beforeEach/afterEach 的使用
  • 學會測試資料的設置和清理
  • 理解測試隔離的概念

TDD 學習地圖

第一階段:打好基礎(Day 1-10)
├── Day 01 - 環境設置與第一個測試
├── Day 02 - 認識斷言(Assertions)
├── Day 03 - TDD 紅綠重構循環
├── Day 04 - 測試結構與組織
├── Day 05 - 測試生命週期 ★ 今天在這裡
├── ...
└── (更多精彩內容待續)

什麼是測試生命週期? 📋

測試執行的階段

每個測試的執行都會經過以下階段:

設置階段 → 執行階段 → 斷言階段 → 清理階段
(Setup)   (Execute)  (Assert)   (Cleanup)
  • 設置階段:準備測試需要的資料和環境
  • 執行階段:呼叫要測試的功能
  • 斷言階段:驗證結果是否符合預期
  • 清理階段:清理測試產生的副作用

沒有生命週期管理的問題

讓我們先看看沒有適當生命週期管理的測試:

// 問題:測試之間會互相影響
$calculator = new CalculatorWithHistory();

it('performs add operation', function() use ($calculator) {
    $result = $calculator->add(2, 3);
    expect($result)->toBe(5);
    expect($calculator->getHistory())->toHaveCount(1);
});

it('performs multiply operation', function() use ($calculator) {
    $result = $calculator->multiply(4, 5);
    expect($result)->toBe(20);
    expect($calculator->getHistory())->toHaveCount(1); // ❌ 實際是 2!
});

第二個測試會失敗,因為計算器的歷史記錄還保留著第一個測試的資料。

測試隔離的重要性 🔒

什麼是測試隔離?

測試隔離是指每個測試案例都應該:

  • 獨立執行:不依賴其他測試的執行結果
  • 環境一致:每次執行都有相同的初始狀態
  • 無副作用:執行後不影響其他測試

使用 beforeEach 解決問題

// ✅ 好的測試:每個測試都是獨立的
describe('Calculator with History', function() {
    beforeEach(function() {
        $this->calculator = new CalculatorWithHistory();
    });
    
    it('performs add operation', function() {
        $result = $this->calculator->add(2, 3);
        expect($result)->toBe(5);
        expect($this->calculator->getHistory())->toHaveCount(1);
    });
    
    it('performs multiply operation', function() {
        $result = $this->calculator->multiply(4, 5);
        expect($result)->toBe(20);
        expect($this->calculator->getHistory())->toHaveCount(1); // 現在會通過
    });
});

beforeEach 的使用 🚀

什麼是 beforeEach?

beforeEach 是在每個測試案例執行「之前」都會執行的函數,用來設置測試環境。

實戰演練:建立 CalculatorWithHistory

建立 app/Math/CalculatorWithHistory.php

<?php

namespace App\Math;

class CalculatorWithHistory
{
    private array $history = [];

    public function add(int|float $a, int|float $b): int|float
    {
        $result = $a + $b;
        $this->recordOperation('add', [$a, $b], $result);
        return $result;
    }

    public function multiply(int|float $a, int|float $b): int|float
    {
        $result = $a * $b;
        $this->recordOperation('multiply', [$a, $b], $result);
        return $result;
    }

    public function getHistory(): array
    {
        return $this->history;
    }

    public function getLastResult(): int|float|null
    {
        return count($this->history) > 0 ? end($this->history)['result'] : null;
    }

    public function clearHistory(): void
    {
        $this->history = [];
    }

    private function recordOperation(string $operation, array $operands, int|float $result): void
    {
        $this->history[] = [
            'operation' => $operation,
            'operands' => $operands,
            'result' => $result,
        ];
    }
}

建立 tests/Unit/Day05/CalculatorLifecycleTest.php

<?php

use App\Math\CalculatorWithHistory;

describe('calculator with history', function() {
    beforeEach(function() {
        // 每個測試開始前都創建一個全新的計算器
        $this->calculator = new CalculatorWithHistory();
    });

    describe('basic operations', function() {
        it('performs addition', function() {
            $result = $this->calculator->add(2, 3);
            
            expect($result)->toBe(5);
            expect($this->calculator->getHistory())->toHaveCount(1);
            expect($this->calculator->getLastResult())->toBe(5);
        });

        it('performs multiplication', function() {
            $result = $this->calculator->multiply(4, 5);
            
            expect($result)->toBe(20);
            expect($this->calculator->getHistory())->toHaveCount(1); // 現在每個測試都是乾淨的
            expect($this->calculator->getLastResult())->toBe(20);
        });
    });

    describe('history functionality', function() {
        it('records single operation', function() {
            $this->calculator->add(2, 3);
            
            $history = $this->calculator->getHistory();
            expect($history)->toHaveCount(1);
            expect($history[0]['operation'])->toBe('add');
            expect($history[0]['operands'])->toBe([2, 3]);
            expect($history[0]['result'])->toBe(5);
        });

        it('records multiple operations', function() {
            $this->calculator->add(2, 3);
            $this->calculator->multiply(4, 5);
            
            $history = $this->calculator->getHistory();
            expect($history)->toHaveCount(2);
            expect($history[0]['operation'])->toBe('add');
            expect($history[1]['operation'])->toBe('multiply');
        });

        it('clears history', function() {
            $this->calculator->add(2, 3);
            $this->calculator->multiply(4, 5);
            expect($this->calculator->getHistory())->toHaveCount(2);
            
            $this->calculator->clearHistory();
            expect($this->calculator->getHistory())->toHaveCount(0);
            expect($this->calculator->getLastResult())->toBe(null);
        });
    });
});

afterEach 的使用 🧹

什麼是 afterEach?

afterEach 是在每個測試案例執行「之後」都會執行的函數,用來清理測試環境。

實戰演練:需要清理的場景

describe('tests requiring cleanup examples', function() {
    beforeEach(function() {
        $this->resource = ['active' => false, 'data' => []];
    });
    
    afterEach(function() {
        // 確保每個測試後都清理資源
        $this->resource['active'] = false;
        $this->resource['data'] = [];
    });

    it('uses resource', function() {
        $this->resource['active'] = true;
        $this->resource['data'][] = 'test';
        
        expect($this->resource['active'])->toBe(true);
        expect($this->resource['data'])->toHaveCount(1);
    });
});

資源管理最佳實踐 💡

對稱的設置與清理

始終保持設置(setup)和清理(cleanup)的對稱性:

describe('resource management examples', function() {
    beforeEach(function() {
        $this->resource = new SomeResource();
        $this->resource->initialize();
    });
    
    afterEach(function() {
        $this->resource->cleanup();
    });
});

避免常見陷阱 ⚠️

常見錯誤 ❌

  1. 忘記清理資源:可能導致記憶體洩漏或測試污染
  2. 設置順序錯誤:先初始化再建立物件會導致錯誤
  3. 過度設置:設置不必要的資源浪費時間

今天學到什麼? 📚

今天我們深入學習了測試生命週期的重要概念:

核心概念

  • 測試生命週期:設置 → 執行 → 斷言 → 清理的完整流程
  • 測試隔離:每個測試獨立執行,互不影響
  • beforeEach/afterEach:自動化的設置和清理機制

實用技巧

  • 對稱設置:設置什麼就清理什麼
  • 最小設置:只設置必要的資源
  • 正確順序:先創建資源再初始化

避免的陷阱

  • 忘記清理:導致資源洩露或測試污染
  • 設置順序錯誤:導致初始化失敗

總結 🎯

測試生命週期是確保測試穩定性和可靠性的基礎。通過適當的設置和清理:

  • 提高測試穩定性:每個測試都在乾淨環境中執行
  • 防止測試污染:測試之間不會互相影響
  • 改善偵錯體驗:失敗的測試更容易定位問題

記住:良好的測試生命週期管理是可靠測試的基石。

明天我們將學習「參數化測試」,了解如何用同一個測試邏輯驗證多組不同的資料,讓測試更加高效和全面。


上一篇
Day 04 - 測試結構與組織 🚀
下一篇
Day 06 - 參數化測試 🔢
系列文
Laravel Pest TDD 實戰:從零開始的測試驅動開發9
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言